import type { HardhatEthers } from "@nomicfoundation/hardhat-ethers/types";

import { HardhatError } from "@nomicfoundation/hardhat-errors";
import { numberToHexString } from "@nomicfoundation/hardhat-utils/hex";

import { REVERT_MATCHER } from "../../constants.js";
import { assertIsNotNull } from "../../utils/asserts.js";
import { buildAssert } from "../../utils/build-assert.js";
import { preventAsyncMatcherChaining } from "../../utils/prevent-chaining.js";

import {
  decodeReturnData,
  getReturnDataFromError,
  parseBytes32String,
} from "./utils.js";

export function supportRevert(
  Assertion: Chai.AssertionStatic,
  chaiUtils: Chai.ChaiUtils,
): void {
  Assertion.addMethod(
    REVERT_MATCHER,
    function (this: any, ethers: HardhatEthers) {
      // capture negated flag before async code executes; see buildAssert's jsdoc
      const negated = this.__flags.negate;

      const subject: unknown = this._obj;

      preventAsyncMatcherChaining(this, REVERT_MATCHER, chaiUtils);

      // Check if the received value can be linked to a transaction, and then
      // get the receipt of that transaction and check its status.
      //
      // If the value doesn't correspond to a transaction, then the `revert`
      // assertion is false.
      const onSuccess = async (value: unknown) => {
        const assert = buildAssert(negated, onSuccess);

        if (isTransactionResponse(value) || typeof value === "string") {
          const hash = typeof value === "string" ? value : value.hash;

          if (!isValidTransactionHash(hash)) {
            throw new HardhatError(
              HardhatError.ERRORS.CHAI_MATCHERS.GENERAL.EXPECTED_VALID_TRANSACTION_HASH,
              {
                hash,
              },
            );
          }

          const receipt = await getTransactionReceipt(ethers, hash);

          if (receipt === null) {
            // If the receipt is null, maybe the string is a bytes32 string
            if (isBytes32String(hash)) {
              assert(false, "Expected transaction to be reverted");
              return;
            }
          }

          assertIsNotNull(receipt, "receipt");
          assert(
            receipt.status === 0,
            "Expected transaction to be reverted",
            "Expected transaction NOT to be reverted",
          );
        } else if (isTransactionReceipt(value)) {
          const receipt = value;

          assert(
            receipt.status === 0,
            "Expected transaction to be reverted",
            "Expected transaction NOT to be reverted",
          );
        } else {
          // If the subject of the assertion is not connected to a transaction
          // (hash, receipt, etc.), then the assertion fails.
          // Since we use `false` here, this means that `.not.to.be.revert`
          // assertions will pass instead of always throwing a validation error.
          // This allows users to do things like:
          //   `expect(c.callStatic.f()).to.not.be.revert`
          assert(false, "Expected transaction to be reverted");
        }
      };

      const onError = (error: any) => {
        const assert = buildAssert(negated, onError);
        const returnData = getReturnDataFromError(error);
        const decodedReturnData = decodeReturnData(returnData);

        if (
          decodedReturnData.kind === "Empty" ||
          decodedReturnData.kind === "Custom"
        ) {
          // in the negated case, if we can't decode the reason, we just indicate
          // that the transaction didn't revert
          assert(true, undefined, `Expected transaction NOT to be reverted`);
        } else if (decodedReturnData.kind === "Error") {
          assert(
            true,
            undefined,
            `Expected transaction NOT to be reverted, but it reverted with reason '${decodedReturnData.reason}'`,
          );
        } else if (decodedReturnData.kind === "Panic") {
          assert(
            true,
            undefined,
            `Expected transaction NOT to be reverted, but it reverted with panic code ${numberToHexString(
              decodedReturnData.code,
            )} (${decodedReturnData.description})`,
          );
        } else {
          const _exhaustiveCheck: never = decodedReturnData;
        }
      };

      // we use `Promise.resolve(subject)` so we can process both values and
      // promises of values in the same way
      const derivedPromise = Promise.resolve(subject).then(onSuccess, onError);

      this.then = derivedPromise.then.bind(derivedPromise);
      this.catch = derivedPromise.catch.bind(derivedPromise);

      return this;
    },
  );
}

async function getTransactionReceipt(ethers: HardhatEthers, hash: string) {
  return ethers.provider.getTransactionReceipt(hash);
}

function isTransactionResponse(x: unknown): x is { hash: string } {
  if (typeof x === "object" && x !== null) {
    return "hash" in x;
  }

  return false;
}

function isTransactionReceipt(x: unknown): x is { status: number } {
  if (typeof x === "object" && x !== null && "status" in x) {
    const status = x.status;

    // this means we only support ethers's receipts for now; adding support for
    // raw receipts, where the status is an hexadecimal string, should be easy
    // and we can do it if there's demand for that
    return typeof status === "number";
  }

  return false;
}

function isValidTransactionHash(x: string): boolean {
  return /0x[0-9a-fA-F]{64}/.test(x);
}

function isBytes32String(v: string): boolean {
  try {
    parseBytes32String(v);
    return true;
  } catch {
    return false;
  }
}
